“Remember: every GPS point is a step toward understanding the world a little better.”

In this lab, we’re diving into the wonderful world of GPS data, our old friend NDVI rasters, and interactive mapping – with a light touch and an emphasis on public health applications. This will be our last interactive session, so we will lean on old tools we’ve been using, and we will also apply some new packages for the first time!

The objectives of this guide are to teach you to:

  1. Understand how to load and preprocess GPS tracking data in R
  2. Visualize GPS data interactively using leaflet
  3. Integrate raster data (NDVI) to represent environmental exposures
  4. Practice cropping, projecting, and overlaying spatial data using terra
  5. Create visually engaging, layered maps using tmap

Let’s do this, one last time!

Load our packages

library(dplyr)        # data wrangling, piping, and general awesomeness
## 
## Attaching package: 'dplyr'
## The following objects are masked from 'package:stats':
## 
##     filter, lag
## The following objects are masked from 'package:base':
## 
##     intersect, setdiff, setequal, union
library(readr)        # reading in data like a pro
library(terra)        # handling raster data
## terra 1.8.29
library(sf)           # spatial vector data with all the bells and whistles
## Linking to GEOS 3.13.0, GDAL 3.8.5, PROJ 9.5.1; sf_use_s2() is TRUE
library(tmap)         # beautiful thematic maps, both static and interactive

#New Packages this week!
library(lubridate)    # working with dates and times like it's no big deal
## 
## Attaching package: 'lubridate'
## The following objects are masked from 'package:terra':
## 
##     intersect, union
## The following objects are masked from 'package:base':
## 
##     date, intersect, setdiff, union
library(leaflet)      # making interactive maps that aim to impress


Import and Prepare Our GPS Data

Here we read in our GPS dataset, parse the timestamp, and create friendly time labels. This is where readr, dplyr, and lubridate come together to easily tackle this task.

download.file("https://raw.githubusercontent.com/pjames-ucdavis/SPH215/refs/heads/main/gps_apr25_30.csv", destfile = "gps_apr25_30.csv", mode = "wb")
gps_data_raw <- read_csv("gps_apr25_30.csv")
## Rows: 21886 Columns: 6
## ── Column specification ────────────────────────────────────────────────────────
## Delimiter: ","
## dbl  (5): timestamp, latitude, longitude, altitude, accuracy
## dttm (1): UTC time
## 
## ℹ Use `spec()` to retrieve the full column specification for this data.
## ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
# Clean it up and extract useful time info
gps_data <- gps_data_raw %>%
  mutate(
    time = ymd_hms(`UTC time`),
    time_label = format(time, "%Y-%m-%d %H:%M:%S"),
    rounded_minute = floor_date(time, unit = "minute")
  ) %>%
  arrange(time)


Mapping with Leaflet

Now let’s use the leaflet package to make an interactive map of our GPS data. This lets us explore movement patterns dynamically, and it’s a great way to get students familiar with spatial data in a hands-on way.

In this example, we:

  • Add a basemap using addProviderTiles()
  • Connect the dots with addPolylines() to show movement
  • Plot each GPS location with addCircleMarkers()
  • Attach a label to each point that shows the timestamp
leaflet(gps_data) %>%
  addProviderTiles("CartoDB.Positron") %>%
  addPolylines(lng = ~longitude, lat = ~latitude, color = "blue", weight = 2) %>%
  addCircleMarkers(
    lng = ~longitude,
    lat = ~latitude,
    radius = 2,
    color = "red",
    popup = ~paste("Time:", time_label),
    label = ~time_label
  )

Looks pretty cool! You just mapped a few thousand points of movement! You can pan, zoom, and click around to explore GPS data in a way that’s engaging and intuitive.


🌿 Add Nature: Load NDVI Raster

Let’s make this a little richer by adding an environmental exposure that we could link to our data. Our old friend NDVI (Normalized Difference Vegetation Index) helps us understand greenness and vegetation density.

download.file("https://raw.githubusercontent.com/pjames-ucdavis/SPH215/refs/heads/main/NDVI_rast_boston.tif", destfile = "NDVI_rast_boston.tif", mode = "wb")
ndvi_raster <- rast("NDVI_rast_boston.tif")


✨ Make It Beautiful with tmap

Time to show off with tmap, our old reliable thematic mapping package. We reproject the GPS points to match the raster and then we layer it all together.

tmap_mode("view")  # switch to interactive map mode
## ℹ tmap modes "plot" - "view"
## ℹ toggle with `tmap::ttm()`
# Reproject GPS to match raster
gps_sf <- st_as_sf(gps_data, coords = c("longitude", "latitude"), crs = 4326)
gps_proj <- st_transform(gps_sf, crs = crs(ndvi_raster))


# Map it!
tm_shape(ndvi_raster) +
  tm_raster(style = "cont", palette = "YlGn", alpha = 0.4, title = "NDVI") +
  tm_shape(gps_proj) +
  tm_dots(col = "blue", size = 0.5, border.col = NA) +
  tm_layout(title = "Where We've Been (with a little green)", legend.outside = TRUE)
## 
## ── tmap v3 code detected ───────────────────────────────────────────────────────
## [v3->v4] `tm_raster()`: instead of `style = "cont"`, use col.scale =
## `tm_scale_continuous()`.
## ℹ Migrate the argument(s) 'palette' (rename to 'values') to
##   'tm_scale_continuous(<HERE>)'[v3->v4] `tm_raster()`: use `col_alpha` instead of `alpha`.[v3->v4] `tm_raster()`: migrate the argument(s) related to the legend of the
## visual variable `col` namely 'title' to 'col.legend = tm_legend(<HERE>)'[v3->v4] `tm_dots()`: use 'fill' for the fill color of polygons/symbols
## (instead of 'col'), and 'col' for the outlines (instead of 'border.col').[v3->v4] `tm_layout()`: use `tm_title()` instead of `tm_layout(title = )`[cols4all] color palettes: use palettes from the R package cols4all. Run
## `cols4all::c4a_gui()` to explore them. The old palette name "YlGn" is named
## "brewer.yl_gn"Registered S3 method overwritten by 'jsonify':
##   method     from    
##   print.json jsonlite
## Warning: tm_scale_intervals `label.style = "continuous"` implementation in view mode
## work in progress


Extract NDVI values to GPS data

Finally, let’s extract NDVI values to GPS data to give us an idea of where exactly we are exposed to greenspace. We will use the extract() function from the terra package to do this.

ndvi_values <- terra::extract(ndvi_raster, vect(gps_proj))


Look at the data

OK, and now we can take a glimpse at the data. This is one participant’s minute-to-minute exposure to greenspace as they move throughout their lives. This is the full distribution of a personal exposure to NDVI for five days! If we had, for instance, physical activity data, we could see if momentary exposure to greenspace is associated with higher physical activity.

summary(ndvi_values$NDVI_mean)
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
## 0.08799 0.33104 0.33498 0.33000 0.33498 0.64671
hist(ndvi_values$NDVI_mean)


🎉 You Did It!

You loaded real-world GPS data, mapped it, added a vegetation raster, made it interactive, and zoomed in with style. We’ve taken our GIS data from the macro to the micro-scale. We’ve covered so much in so little time! It has been a mapping whirlwind this quarter, and I’m so proud of all of you!

Now go forth, and happy mapping! 🗺️💪

LS0tCnRpdGxlOiAiTGFiIDk6IEZ1dHVyZSBEaXJlY3Rpb25zIGluIEdJUyBhbmQgUHVibGljIEhlYWx0aCIKLS0tCgo+ICJSZW1lbWJlcjogZXZlcnkgR1BTIHBvaW50IGlzIGEgc3RlcCB0b3dhcmQgdW5kZXJzdGFuZGluZyB0aGUgd29ybGQgYSBsaXR0bGUgYmV0dGVyLiIKCkluIHRoaXMgbGFiLCB3ZSdyZSBkaXZpbmcgaW50byB0aGUgd29uZGVyZnVsIHdvcmxkIG9mICoqR1BTIGRhdGEqKiwgb3VyIG9sZCBmcmllbmQgKipORFZJIHJhc3RlcnMqKiwgYW5kICoqaW50ZXJhY3RpdmUgbWFwcGluZyoqIOKAkyB3aXRoIGEgbGlnaHQgdG91Y2ggYW5kIGFuIGVtcGhhc2lzIG9uIHB1YmxpYyBoZWFsdGggYXBwbGljYXRpb25zLiBUaGlzIHdpbGwgYmUgb3VyIGxhc3QgaW50ZXJhY3RpdmUgc2Vzc2lvbiwgc28gd2Ugd2lsbCBsZWFuIG9uIG9sZCB0b29scyB3ZSd2ZSBiZWVuIHVzaW5nLCBhbmQgd2Ugd2lsbCBhbHNvIGFwcGx5IHNvbWUgbmV3IHBhY2thZ2VzIGZvciB0aGUgZmlyc3QgdGltZSEgCgpUaGUgb2JqZWN0aXZlcyBvZiB0aGlzIGd1aWRlIGFyZSB0byB0ZWFjaCB5b3UgdG86CgoxLiBVbmRlcnN0YW5kIGhvdyB0byBsb2FkIGFuZCBwcmVwcm9jZXNzIEdQUyB0cmFja2luZyBkYXRhIGluIFIKMi4gVmlzdWFsaXplIEdQUyBkYXRhIGludGVyYWN0aXZlbHkgdXNpbmcgKipsZWFmbGV0KioKMy4gSW50ZWdyYXRlIHJhc3RlciBkYXRhIChORFZJKSB0byByZXByZXNlbnQgZW52aXJvbm1lbnRhbCBleHBvc3VyZXMKNC4gUHJhY3RpY2UgY3JvcHBpbmcsIHByb2plY3RpbmcsIGFuZCBvdmVybGF5aW5nIHNwYXRpYWwgZGF0YSB1c2luZyAqKnRlcnJhKioKNS4gQ3JlYXRlIHZpc3VhbGx5IGVuZ2FnaW5nLCBsYXllcmVkIG1hcHMgdXNpbmcgKip0bWFwKioKCkxldCdzIGRvIHRoaXMsIG9uZSBsYXN0IHRpbWUhCi0tLQoKIyBMb2FkIG91ciBwYWNrYWdlcwoKYGBge3J9CmxpYnJhcnkoZHBseXIpICAgICAgICAjIGRhdGEgd3JhbmdsaW5nLCBwaXBpbmcsIGFuZCBnZW5lcmFsIGF3ZXNvbWVuZXNzCmxpYnJhcnkocmVhZHIpICAgICAgICAjIHJlYWRpbmcgaW4gZGF0YSBsaWtlIGEgcHJvCmxpYnJhcnkodGVycmEpICAgICAgICAjIGhhbmRsaW5nIHJhc3RlciBkYXRhCmxpYnJhcnkoc2YpICAgICAgICAgICAjIHNwYXRpYWwgdmVjdG9yIGRhdGEgd2l0aCBhbGwgdGhlIGJlbGxzIGFuZCB3aGlzdGxlcwpsaWJyYXJ5KHRtYXApICAgICAgICAgIyBiZWF1dGlmdWwgdGhlbWF0aWMgbWFwcywgYm90aCBzdGF0aWMgYW5kIGludGVyYWN0aXZlCgojTmV3IFBhY2thZ2VzIHRoaXMgd2VlayEKbGlicmFyeShsdWJyaWRhdGUpICAgICMgd29ya2luZyB3aXRoIGRhdGVzIGFuZCB0aW1lcyBsaWtlIGl0J3Mgbm8gYmlnIGRlYWwKbGlicmFyeShsZWFmbGV0KSAgICAgICMgbWFraW5nIGludGVyYWN0aXZlIG1hcHMgdGhhdCBhaW0gdG8gaW1wcmVzcwpgYGAKClwKCiMgSW1wb3J0IGFuZCBQcmVwYXJlIE91ciBHUFMgRGF0YQoKSGVyZSB3ZSByZWFkIGluIG91ciBHUFMgZGF0YXNldCwgcGFyc2UgdGhlIHRpbWVzdGFtcCwgYW5kIGNyZWF0ZSBmcmllbmRseSB0aW1lIGxhYmVscy4gVGhpcyBpcyB3aGVyZSAqKnJlYWRyKiosICoqZHBseXIqKiwgYW5kICoqbHVicmlkYXRlKiogY29tZSB0b2dldGhlciB0byBlYXNpbHkgdGFja2xlIHRoaXMgdGFzay4KYGBge3J9CmRvd25sb2FkLmZpbGUoImh0dHBzOi8vcmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbS9wamFtZXMtdWNkYXZpcy9TUEgyMTUvcmVmcy9oZWFkcy9tYWluL2dwc19hcHIyNV8zMC5jc3YiLCBkZXN0ZmlsZSA9ICJncHNfYXByMjVfMzAuY3N2IiwgbW9kZSA9ICJ3YiIpCmdwc19kYXRhX3JhdyA8LSByZWFkX2NzdigiZ3BzX2FwcjI1XzMwLmNzdiIpCgojIENsZWFuIGl0IHVwIGFuZCBleHRyYWN0IHVzZWZ1bCB0aW1lIGluZm8KZ3BzX2RhdGEgPC0gZ3BzX2RhdGFfcmF3ICU+JQogIG11dGF0ZSgKICAgIHRpbWUgPSB5bWRfaG1zKGBVVEMgdGltZWApLAogICAgdGltZV9sYWJlbCA9IGZvcm1hdCh0aW1lLCAiJVktJW0tJWQgJUg6JU06JVMiKSwKICAgIHJvdW5kZWRfbWludXRlID0gZmxvb3JfZGF0ZSh0aW1lLCB1bml0ID0gIm1pbnV0ZSIpCiAgKSAlPiUKICBhcnJhbmdlKHRpbWUpCmBgYAoKXAoKCiMgTWFwcGluZyB3aXRoIExlYWZsZXQKCk5vdyBsZXTigJlzIHVzZSB0aGUgKipsZWFmbGV0KiogcGFja2FnZSB0byBtYWtlIGFuIGludGVyYWN0aXZlIG1hcCBvZiBvdXIgR1BTIGRhdGEuIFRoaXMgbGV0cyB1cyBleHBsb3JlIG1vdmVtZW50IHBhdHRlcm5zIGR5bmFtaWNhbGx5LCBhbmQgaXTigJlzIGEgZ3JlYXQgd2F5IHRvIGdldCBzdHVkZW50cyBmYW1pbGlhciB3aXRoIHNwYXRpYWwgZGF0YSBpbiBhIGhhbmRzLW9uIHdheS4KCkluIHRoaXMgZXhhbXBsZSwgd2U6CgotIEFkZCBhIGJhc2VtYXAgdXNpbmcgYGFkZFByb3ZpZGVyVGlsZXMoKWAKLSBDb25uZWN0IHRoZSBkb3RzIHdpdGggYGFkZFBvbHlsaW5lcygpYCB0byBzaG93IG1vdmVtZW50Ci0gUGxvdCBlYWNoIEdQUyBsb2NhdGlvbiB3aXRoIGBhZGRDaXJjbGVNYXJrZXJzKClgCi0gQXR0YWNoIGEgbGFiZWwgdG8gZWFjaCBwb2ludCB0aGF0IHNob3dzIHRoZSB0aW1lc3RhbXAKCmBgYHtyfQpsZWFmbGV0KGdwc19kYXRhKSAlPiUKICBhZGRQcm92aWRlclRpbGVzKCJDYXJ0b0RCLlBvc2l0cm9uIikgJT4lCiAgYWRkUG9seWxpbmVzKGxuZyA9IH5sb25naXR1ZGUsIGxhdCA9IH5sYXRpdHVkZSwgY29sb3IgPSAiYmx1ZSIsIHdlaWdodCA9IDIpICU+JQogIGFkZENpcmNsZU1hcmtlcnMoCiAgICBsbmcgPSB+bG9uZ2l0dWRlLAogICAgbGF0ID0gfmxhdGl0dWRlLAogICAgcmFkaXVzID0gMiwKICAgIGNvbG9yID0gInJlZCIsCiAgICBwb3B1cCA9IH5wYXN0ZSgiVGltZToiLCB0aW1lX2xhYmVsKSwKICAgIGxhYmVsID0gfnRpbWVfbGFiZWwKICApCmBgYAoKTG9va3MgcHJldHR5IGNvb2whIFlvdSBqdXN0IG1hcHBlZCBhIGZldyB0aG91c2FuZCBwb2ludHMgb2YgbW92ZW1lbnQhIFlvdSBjYW4gcGFuLCB6b29tLCBhbmQgY2xpY2sgYXJvdW5kIHRvIGV4cGxvcmUgR1BTIGRhdGEgaW4gYSB3YXkgdGhhdCdzIGVuZ2FnaW5nIGFuZCBpbnR1aXRpdmUuCgpcCgojIPCfjL8gQWRkIE5hdHVyZTogTG9hZCBORFZJIFJhc3RlcgoKTGV0J3MgbWFrZSB0aGlzIGEgbGl0dGxlIHJpY2hlciBieSBhZGRpbmcgYW4gZW52aXJvbm1lbnRhbCBleHBvc3VyZSB0aGF0IHdlIGNvdWxkIGxpbmsgdG8gb3VyIGRhdGEuIE91ciBvbGQgZnJpZW5kIE5EVkkgKE5vcm1hbGl6ZWQgRGlmZmVyZW5jZSBWZWdldGF0aW9uIEluZGV4KSBoZWxwcyB1cyB1bmRlcnN0YW5kIGdyZWVubmVzcyBhbmQgdmVnZXRhdGlvbiBkZW5zaXR5LiAKCmBgYHtyfQpkb3dubG9hZC5maWxlKCJodHRwczovL3Jhdy5naXRodWJ1c2VyY29udGVudC5jb20vcGphbWVzLXVjZGF2aXMvU1BIMjE1L3JlZnMvaGVhZHMvbWFpbi9ORFZJX3Jhc3RfYm9zdG9uLnRpZiIsIGRlc3RmaWxlID0gIk5EVklfcmFzdF9ib3N0b24udGlmIiwgbW9kZSA9ICJ3YiIpCm5kdmlfcmFzdGVyIDwtIHJhc3QoIk5EVklfcmFzdF9ib3N0b24udGlmIikKCmBgYAoKXAoKIyDinKggTWFrZSBJdCBCZWF1dGlmdWwgd2l0aCB0bWFwCgpUaW1lIHRvIHNob3cgb2ZmIHdpdGggKip0bWFwKiosIG91ciBvbGQgcmVsaWFibGUgdGhlbWF0aWMgbWFwcGluZyBwYWNrYWdlLiBXZSByZXByb2plY3QgdGhlIEdQUyBwb2ludHMgdG8gbWF0Y2ggdGhlIHJhc3RlciBhbmQgdGhlbiB3ZSBsYXllciBpdCBhbGwgdG9nZXRoZXIuCmBgYHtyfQp0bWFwX21vZGUoInZpZXciKSAgIyBzd2l0Y2ggdG8gaW50ZXJhY3RpdmUgbWFwIG1vZGUKCiMgUmVwcm9qZWN0IEdQUyB0byBtYXRjaCByYXN0ZXIKZ3BzX3NmIDwtIHN0X2FzX3NmKGdwc19kYXRhLCBjb29yZHMgPSBjKCJsb25naXR1ZGUiLCAibGF0aXR1ZGUiKSwgY3JzID0gNDMyNikKZ3BzX3Byb2ogPC0gc3RfdHJhbnNmb3JtKGdwc19zZiwgY3JzID0gY3JzKG5kdmlfcmFzdGVyKSkKCgojIE1hcCBpdCEKdG1fc2hhcGUobmR2aV9yYXN0ZXIpICsKICB0bV9yYXN0ZXIoc3R5bGUgPSAiY29udCIsIHBhbGV0dGUgPSAiWWxHbiIsIGFscGhhID0gMC40LCB0aXRsZSA9ICJORFZJIikgKwogIHRtX3NoYXBlKGdwc19wcm9qKSArCiAgdG1fZG90cyhjb2wgPSAiYmx1ZSIsIHNpemUgPSAwLjUsIGJvcmRlci5jb2wgPSBOQSkgKwogIHRtX2xheW91dCh0aXRsZSA9ICJXaGVyZSBXZSd2ZSBCZWVuICh3aXRoIGEgbGl0dGxlIGdyZWVuKSIsIGxlZ2VuZC5vdXRzaWRlID0gVFJVRSkKYGBgCgpcCgojIEV4dHJhY3QgTkRWSSB2YWx1ZXMgdG8gR1BTIGRhdGEKCkZpbmFsbHksIGxldCdzIGV4dHJhY3QgTkRWSSB2YWx1ZXMgdG8gR1BTIGRhdGEgdG8gZ2l2ZSB1cyBhbiBpZGVhIG9mIHdoZXJlIGV4YWN0bHkgd2UgYXJlIGV4cG9zZWQgdG8gZ3JlZW5zcGFjZS4gV2Ugd2lsbCB1c2UgdGhlIGBleHRyYWN0KClgIGZ1bmN0aW9uIGZyb20gdGhlICoqdGVycmEqKiBwYWNrYWdlIHRvIGRvIHRoaXMuCmBgYHtyfQpuZHZpX3ZhbHVlcyA8LSB0ZXJyYTo6ZXh0cmFjdChuZHZpX3Jhc3RlciwgdmVjdChncHNfcHJvaikpCgpgYGAKClwKCiMgTG9vayBhdCB0aGUgZGF0YQoKT0ssIGFuZCBub3cgd2UgY2FuIHRha2UgYSBnbGltcHNlIGF0IHRoZSBkYXRhLiBUaGlzIGlzIG9uZSBwYXJ0aWNpcGFudCdzIG1pbnV0ZS10by1taW51dGUgZXhwb3N1cmUgdG8gZ3JlZW5zcGFjZSBhcyB0aGV5IG1vdmUgdGhyb3VnaG91dCB0aGVpciBsaXZlcy4gVGhpcyBpcyB0aGUgZnVsbCBkaXN0cmlidXRpb24gb2YgYSBwZXJzb25hbCBleHBvc3VyZSB0byBORFZJIGZvciBmaXZlIGRheXMhIElmIHdlIGhhZCwgZm9yIGluc3RhbmNlLCBwaHlzaWNhbCBhY3Rpdml0eSBkYXRhLCB3ZSBjb3VsZCBzZWUgaWYgbW9tZW50YXJ5IGV4cG9zdXJlIHRvIGdyZWVuc3BhY2UgaXMgYXNzb2NpYXRlZCB3aXRoIGhpZ2hlciBwaHlzaWNhbCBhY3Rpdml0eS4KYGBge3J9CnN1bW1hcnkobmR2aV92YWx1ZXMkTkRWSV9tZWFuKQpoaXN0KG5kdmlfdmFsdWVzJE5EVklfbWVhbikKYGBgCgpcCgojIyDwn46JIFlvdSBEaWQgSXQhCgpZb3UgbG9hZGVkIHJlYWwtd29ybGQgR1BTIGRhdGEsIG1hcHBlZCBpdCwgYWRkZWQgYSB2ZWdldGF0aW9uIHJhc3RlciwgbWFkZSBpdCBpbnRlcmFjdGl2ZSwgYW5kIHpvb21lZCBpbiB3aXRoIHN0eWxlLiBXZSd2ZSB0YWtlbiBvdXIgR0lTIGRhdGEgZnJvbSB0aGUgbWFjcm8gdG8gdGhlIG1pY3JvLXNjYWxlLiBXZSd2ZSBjb3ZlcmVkIHNvIG11Y2ggaW4gc28gbGl0dGxlIHRpbWUhIEl0IGhhcyBiZWVuIGEgbWFwcGluZyB3aGlybHdpbmQgdGhpcyBxdWFydGVyLCBhbmQgSSdtIHNvIHByb3VkIG9mIGFsbCBvZiB5b3UhCgpOb3cgZ28gZm9ydGgsIGFuZCBoYXBweSBtYXBwaW5nISDwn5e677iP8J+Sqg==